Authentication - 基于 Gateway 网关的响应式双 Token 认证实现

安全架构的持续演进

我们知道,认证实际包含两个部分,第一是如何认证(密码认证、二维码认证、手机验证码认证 、刷脸认证等等),第二是认证通过之后认证的状态如何保持。本文我们主要探究的问题是认证状态的保持(会话保持)。


问题和技术应对

首先来看,为了会话的持续安全,我们一般会在技术上做哪些调整:

  1. 早期的 Session-Cookie 机制,是服务器试图用 “记忆” 来对抗时间。服务器在内存中为用户留出一个位置(Session ID),每一次请求都是一次记忆的唤醒。当系统从单体演化为微服务,多台机器无法共享内存(“失忆”)。为了维持会话记忆,系统不得不付出高昂的成本(如 Redis Session 共享),把 “记忆” 变成中心化的沉重负担。
  2. 为了摆脱记忆枷锁,衍生出 JWT(JSON Web Token)技术。它不再依赖服务器的记忆,而是将 “身份、权益、过期时间” 打包,用密钥(Secret Key)进行数字签名,固化成为一份不可篡改的契约。
  3. 可是 JWT 虽然释放了服务器的内存,但却带来了“覆水难收” 的问题。一旦一份有效期 7 天的 Token 被签发出去,它就变成了一个在网络中不受控流窜的幽灵。如果将 Token 过期时间设置的很短,比如5分钟,那么每5分钟用户就要重新登录认证一次,体验感就会大打折扣。
  4. 为了平衡安全性和用户的体验,微服务系统演进出了 “双 Token 方案”,它本质上是对时间维度的精细化切割:AccessToken(15分钟)代表高频的日常物权,它短命、轻量,死不足惜,即便它真的被窃取,攻击者也只能疯狂 15 分钟。RefreshToken(7天)代表低频的根本主权。它长寿、沉重,只在置换时露面。当短命的 AccessToken 过期失效时,客户端可以拿长寿的 refreshToken 换取新的 AccessToken。
  5. 为彻底解决下发的 token 无法立即收回的问题,需要增加黑名单校验的逻辑。另外,为解决 refreshToken 重放攻击的问题,也需要设置 refreshToken 的状态标记 。为实现这两点,我们引入 redis 来维护这两组状态数据。并且使用适当的降级策略,保证 redis 的宕机或超时不会影响 Gateway 网关认证的主流程。
  6. 除此之外,微服务内部的通信也不能认为是绝对完全的,攻击者可能入侵到网络内部,发起接口调用。为此,我们在网关层验证 accessToken 成功之后,清理掉外层访问带来的 Authorization,给下游服务下发一个内网专用的 “防伪水印”,过期时间设置的很短,比如 10秒钟。请求到达下游节点A,必须先进行 “防伪水印” 的合法性校验。如果下游节点 A 还需要调用更下游的服务节点 B,必须也要将 “防伪水印” 透传到节点B。如果在透传的时候 A 发现水印即将或者已经过期,节点 A 可以生成一个新的 “防伪水印”,继续透传到 B。当然,也并非所有下游接口都需要防伪水印,对于那些需要防伪水印的接口增加注解即可。


核心双密钥

全系统仅通过两个密钥的隔离,实现了 “外网防伪伪造,内网防越权偷袭” 的极简设计。

密钥名称 作用域 核心职责 生效时长
jwt.secret.external (外网密钥) 仅网关持有 负责生成与解析暴露给前端的 AccessToken 和 RefreshToken。隔离了外网的直接攻击。 AccessToken: 15分钟 RefreshToken: 7天
jwt.secret.internal (内网密钥) 网关与所有下游微服务共享 负责生成与解析内网防伪水印 X-Gateway-Token。防止黑客绕过网关直接偷袭内网微服务端口。 10 秒级(闪启闪失效)


全局调用链

整体架构上,各模块各司其职,有明确的职责边界:网关的归网关,用户中心的归用户中心,公共 starter 的归 公共 starter。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
            🔥 外网边界 (Access Token / Refresh Token)
[ 客户端 ] ──────────────────────────────┐


┌──────────────┐
External Key │ demo-gateway │ ◄── [全局 Redis 黑名单/防刷]
Internal Key └──────┬───────┘
│ 清洗 Header + 注入 10s 内网防伪水印

⚡ 内网零信任边界 (Internal Key)
┌────────────────────────┴────────────────────────┐
▼ ▼
┌─────────────────────────┐ ┌─────────────────────────┐
│ demo-user │ │ 其他业务微服务集群 │
│ (纯粹账密核验/BCrypt密码) │ │ (Order/Product/etc...) │
└─────────────────────────┘ └─────────────────────────┘
[ 核心依赖: demo-security-spring-boot-starter ] [ 核心依赖: demo-security-spring-boot-starter ]
[ 职能: 验证 10s 水印 -> 注入 ThreadLocal 上下文 ] [ 职能: 验证 10s 水印 -> 注入 ThreadLocal 上下文 ]


主要调用逻辑

登录与token颁发

把 Token 颁发收拢到网关后,用户中心退化为纯粹的 “账密核验器”,从而让网关作为唯一的 “安全凭证签发所”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[ 前端客户端 ]              [ 统一网关 Gateway ]          [ 用户微服务 User-Service ]
│ │ │
│ 1. 提交账密 (POST /login) │ │
├─────────────────────────────>│ │
│ │ 2. 纯透传账密 (RPC/内部转发) │
│ ├─────────────────────────────>│
│ │ │ 3. 查库比对 BCrypt 密码
│ │ │ (确认身份正确)
│ │ 4. 返回核验结果 (userId/role) │
│ │<─────────────────────────────┤
│ │
│ │ 5. 确认识别成功
│ │ 使用 external 密钥统一
│ │ 生成双 Token:
│ │ - AccessToken (15m)
│ │ - RefreshToken (7d)
│ 6. 返回双 Token │
│<─────────────────────────────┤


请求与内网防伪

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
[ 前端客户端 ]              [ 统一网关 Gateway ]          [ 下游微服务 (Starter拦截器) ]
│ │ │
│ 1. 携带 AccessToken 请求业务 │ │
├─────────────────────────────>│ │
│ │ 2. 响应式无阻塞校验:
│ │ - 查 Redis 用户黑名单 (踢人)
│ │ - 用 external 校验/解析 JWT
│ │
│ │ 3. 请求头彻底清洗 (Clean Headers):
│ │ - 抹除前端带入的 Authorization
│ │ - 注入明文 X-User-Id, X-User-Role
│ │ - 用 internal 签署 10s 级
│ │ 内网水印 X-Gateway-Token
│ │
│ │ 4. 路由转发 (携带全新 Headers) │
│ ├─────────────────────────────>│
│ │ │ 5. Starter 核心拦截器:
│ │ │ - 验证内网防伪水印
│ │ │ - 校验时间戳 (超10s抛异常)
│ │ │ - 提取用户信息,注入
│ │ │ ThreadLocal 业务上下文
│ │ │
│ │ │ 6. 执行 Controller 核心业务
│ │ 7. 返回业务响应数据 │
│<─────────────────────────────┴──────────────────────────────┤


Token 静默续期

当 AccessToken 15分钟到期时,通过高性能非阻塞响应式置换机制,实现无感滑行续期,并自带防刷重放功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
[ 前端客户端 ]              [ 统一网关 Gateway ]          [ Redis 异步缓存集群 ]
│ │ │
│ 1. 携带已过期的 AccessToken │ │
├─────────────────────────────>│ │
│ 2. 网关抛出 401 并拒绝请求 │ │
│<─────────────────────────────┤ │
│ │
│ ====== 前端捕获 401 拦截器启动,触发静默续期 ====== │
│ │
│ 3. 请求 /auth/refresh 接口 │ │
│ (Body 携带长效 RefreshToken) │
├─────────────────────────────>│ │
│ │ 4. 异步非阻塞校验 (Mono.defer):
│ │ - 用 external 校验并解析长 Token
│ │
│ │ 5. 异步检查全局强制封禁黑名单 │
│ ├─────────────────────────────> │
│ │<── 返回 false (未封禁) ──────┤
│ │
│ │ 6. 高级防刷:异步检查该长 Token
│ │ 在5分钟内是否被重放/重复消费过
│ ├─────────────────────────────> │
│ │<── 返回 false (安全,未复用) ─┤
│ │
│ │ 7. 安全过关,生成:
│ │ - 全新短效 AccessToken
│ │ - 全新长效 RefreshToken
│ │
│ │ 8. 异步将旧长 Token 标记为
│ │ “已消费”,失效 5 分钟 (容忍抖动)
│ ├─────────────────────────────> │
│ 9. 返回全新双 Token 覆盖本地 │ │
│<─────────────────────────────┤ │


不足和改进

目前存在的不足之处:

  • 首先,不管是全链路自动续期,还是注解驱动的防御,其底层根基都在于:所有的微服务都必须在本地拥有相同的 jwt.secret.internal(内网共享密钥),用来生成和校验 X-Gateway-Token。在大型微服务团队中,代码由不同的开发人员编写,部署在不同的容器里。只要有任意一个边缘服务的代码泄露,或者某个不重要的微服务服务器被黑客攻破并反编译了配置,整个帝国的内网零信任防线会在瞬间 “全盘坍塌”。黑客拿到这枚内网密钥后,可以在内网伪造任意用户、任意时间戳的完美水印,下游所有 Starter 哨所都会直接 “倒戈”,甚至连网关都无法察觉。
  • 其次,下游 Starter 收到水印后,通过当前时间减去水印时间戳($\Delta t = t_{\text{now}} - t_{\text{token}}$),若大于 10 秒则拒绝服务,以此防止黑客在内网录音重放。但这个设计对时钟同步的要求很高,在云原生、多机房或者 K8s 虚拟化集群中,各台物理机、虚拟机之间的时钟发生微小的时钟漂移(Clock Drift)是常态。如果服务 A 的物理机时钟比网关慢了 6 秒,而服务 B 却比网关快了 5 秒。那么当一个请求从小慢服务 A 转发到大快服务 B 时,服务 B 算出来的 $\Delta t$ 瞬间就会超过 11 秒,直接引发合法业务被内网哨所误杀,导致诡异的系统偶发性熔断。

针对以上两个问题,提出的改进方案:

  • 非对称加密升级:网关持有私钥(独签),下游 Starter 仅持有公钥(只验不签)。下游之间调用不再“就地续期”,而是向网关或认证中心换取专用的 Service-to-Service 凭证(类似于 Kerberos 票据),彻底隔离用户密钥与服务密钥。
    • 服务 A 申请票据时,须提供自己独一无二的机器凭证(比如一出生就配置好的 Client_ID 和 Client_Secret)。
    • 认证中心验证了服务 A 的身份,用私钥当场印了一张专属的 S2S 凭证(机器间通信 Token)。
    • 这张通行证的 Payload 长这样: {“from”: “Service-A”, “to”: “Service-B”, “user_id”: “1024”, “exp”: “10秒后过期”}。
    • 服务 A 拿到这张热乎的 S2S 通行证,塞进 HTTP Header,发给服务 B。
    • 服务 B 的 Starter 收到请求,拿出公钥一验:签名是真的(网关私钥签的)、确实是发给我(to: Service-B)的、时间戳没过期、顺便还能读出是服务 A 代表用户 1024 来访问,此时服务 B 放心放行,执行业务。
  • 拥抱 Service Mesh(服务网格):将 Starter 中的 “零信任水印防御 ” 与 “Feign 拦截透传” 逻辑完全剥离,直接下沉到 Istio / Envoy 的 Sidecar 中。也就是将安全和认证的逻辑,从代码层彻底剥离,变成基础设施(网络层)的标配。在网络层(mTLS,双向 TLS 认证)直接把内网越权掐死,让业务开发彻底回归纯粹,免受 ThreadLocal 丢失之苦。


具体代码实现

父项目POM

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
<modules>
<module>zdemo-gateway</module>
<module>zdemo-user</module>
<module>zdemo-security-spring-boot-starter</module>
</modules>

<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

<spring-boot.version>3.3.0</spring-boot.version>
<spring-cloud.version>2023.0.1</spring-cloud.version>
<lombok.version>1.18.44</lombok.version>
<mapstruct.version>1.5.5.Final</mapstruct.version>
<jjwt.version>0.12.7</jjwt.version>
</properties>

<dependencyManagement>
<dependencies>
<!--版本说明:https://spring.io/projects/spring-boot#support-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>

<!--版本说明:https://spring.io/projects/spring-cloud#support-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>

<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
</dependency>
</dependencies>
</dependencyManagement>

<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<scope>provided</scope>
</dependency>
</dependencies>


安全 starter 组件

组件定位和职责

我们构建一个自己编写的 demo-security-spring-boot-starter,负责在每个微服务应用的门口检查:“没有网关关隘发的 10 秒特许令,谁也别想进我的 Controller 营帐。只要令符是真的,我就把他的名字写在营帐的桌子上(ThreadLocal),供里面的业务人员随时查看。”

  • 角色定位:它作为一个通用的、可复用的 SDK 组件。它不信任任何网络环境,只认网关的防伪钢印,负责解决内网的 “零信任安全” 和研发体验的解耦。
  • 具体职能:
    • 内网防伪水印核验(防偷袭):每一个微服务引入该 Starter 后,其内置的 HandlerInterceptor 会在 Controller 执行前强制拦截请求。它使用与网关共享的内网密钥 jwt.secret.internal 解密 X-Gateway-Token。如果解密失败,说明有黑客绕过网关,试图在内网端口直接调用该微服务,直接拒绝访问。
    • 明文 Header 提取与线程上下文注入:一旦水印验明正身,拦截器会从 Header 中直接读取网关清洗并注入的 X-User-Id 和 X-User-Role。通过自定义的 UserContext,将用户信息瞬间注入到当前线程的 ThreadLocal 变量中。


依赖配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
<dependencies>
<!-- 1. Spring Boot 自动装配核心依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
<scope>provided</scope>
</dependency>

<!-- 2. 用于绑定 application.yml 配置文件的注解处理器 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>

<!-- 3. 下游微服务依赖:Spring webmvc (Servlet) -->
<!-- 设置为 optional=true,这样网关引入当前 starter 时,不会把 Tomcat 依赖拉过去导致冲突 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-core</artifactId>
<optional>true</optional>
</dependency>

<!-- 4. JJWT 核心 API 依赖 -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>

<!-- 5. JJWT 实现与 JSON 序列化依赖 (生产环境运行时必须) -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>

<!-- 6. 基础工具:Lombok 与日志 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>


自动装配组件

OwliasSecurityAutoConfiguration

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import zdemo.intercepter.InternalSecurityInterceptor;
import zdemo.intercepter.ReactiveInternalSecurityFilter;
import zdemo.model.property.OwliasSecurityProperties;
import zdemo.utils.OwliasJwtUtil;

@Configuration
@EnableConfigurationProperties(OwliasSecurityProperties.class)
public class OwliasSecurityAutoConfiguration {

/**
* 注入 OwliasJwtUtil
* 论网关还是下游,都需要这个工具类,全局共享
*/
@Bean
public OwliasJwtUtil owliasJwtUtil(OwliasSecurityProperties properties) {
return new OwliasJwtUtil(properties);
}

/**
* 情况 A:如果检测到引入此 Starter 的是传统的 Servlet (WebMvc) 服务
* 激活条件:传统 Servlet WEB 环境
*/
@Configuration
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
public static class DownstreamInterceptorConfiguration implements WebMvcConfigurer {
private final OwliasJwtUtil jwtUtil;
private final OwliasSecurityProperties owliasSecurityProperties;
public DownstreamInterceptorConfiguration(
OwliasJwtUtil jwtUtil, OwliasSecurityProperties owliasSecurityProperties) {
this.jwtUtil = jwtUtil;
this.owliasSecurityProperties = owliasSecurityProperties;
}

@Override
public void addInterceptors(InterceptorRegistry registry) {
// 默认拦截所有请求,自动进行内网防伪校验与上下文注入
registry.addInterceptor(new InternalSecurityInterceptor(jwtUtil))
.addPathPatterns("/**")
.excludePathPatterns(owliasSecurityProperties.getExcludePathPatterns());
}
}

/**
* 情况 B:如果检测到引入此 Starter 的是响应式 (Reactive/WebFlux) 服务
* 激活条件:Reactive 环境 + 必须排除网关模块
*/
@Configuration
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE)
@ConditionalOnMissingClass("org.springframework.cloud.gateway.filter.GlobalFilter")
public static class ReactiveFilterConfiguration {
@Bean
public ReactiveInternalSecurityFilter reactiveInternalSecurityFilter(OwliasJwtUtil jwtUtil) {
return new ReactiveInternalSecurityFilter(jwtUtil);
}
}
}

resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

1
zdemo.autoconfig.OwliasSecurityAutoConfiguration

OwliasSecurityProperties

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.ArrayList;
import java.util.List;

@Data
@ConfigurationProperties(prefix = "owlias.security")
public class OwliasSecurityProperties {
private String externalSecret; // 外网 JWT 密钥
private String internalSecret; // 内网水印密钥
private int clockSkewSeconds = 2; // 时钟容忍度,默认 2 秒

// 内网不校验 token 的资源列表
private List<String> excludePathPatterns = new ArrayList<>();
}

InternalSecurityInterceptor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.security.SignatureException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import zdemo.model.common.InternalSecure;
import zdemo.model.common.UserContext;
import zdemo.utils.OwliasJwtUtil;

/**
* 下游普通web专属:网关防伪校验拦截器
*/
@Slf4j
@Component
public class InternalSecurityInterceptor implements HandlerInterceptor {

private final OwliasJwtUtil jwtUtil;
public InternalSecurityInterceptor(OwliasJwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String gatewayToken = request.getHeader("X-Gateway-Token");
String userId = request.getHeader("X-User-Id");
String role = request.getHeader("X-User-Role");

// 1. 如果请求没有映射到 Controller 的具体方法(例如静态资源),直接放行
if (!(handler instanceof HandlerMethod handlerMethod)) {
return true;
}

// 2. 检查方法上或类上是否打上了 @InternalSecure 注解,没有注解或者显式写了 required = false 视为绿色通道,直接放行
InternalSecure internalSecure = handlerMethod.getMethodAnnotation(InternalSecure.class);
if (internalSecure == null) {
internalSecure = handlerMethod.getBeanType().getAnnotation(InternalSecure.class);
}
if (internalSecure == null || !internalSecure.required()) {
// 即使不需要水印,如果 Header 里顺带传了用户信息,也顺手解析进 ThreadLocal,方便业务使用
UserContext.setUserId(Long.parseLong(userId));
UserContext.setRole(role);
return true;
}

// 3. 严格防绕过:如果关键 Header 为空,说明有恶意请求试图绕过网关直接内网偷袭微服务端口
if (gatewayToken == null || userId == null) {
log.warn("Security Violation: Attempted to bypass gateway from IP: {}", request.getRemoteAddr());
return sendErrorResponse(response, HttpStatus.FORBIDDEN, "Security Violation: Direct access forbidden.");
}

try {
// 4. 双重签名校验:解密内网防伪水印(OwliasJwtUtil 内部已集成 application.yml 配置的 clockSkewSeconds 容忍度)
Claims claims = jwtUtil.parseInternalToken(gatewayToken);

// 5. 严格比对防篡改:防伪令牌里的 Subject 必须等于 Header 里的 X-User-Id
// 若网关曾经因为 .claims(Map) 语法导致 sub 被冲洗,claims.getSubject() 会拿不到值,此处能精准识别
if (claims.getSubject() == null || !userId.equals(claims.getSubject())) {
log.error("Identity forgery detected! Header userId: {}, JWT sub: {}", userId, claims.getSubject());
return sendErrorResponse(response, HttpStatus.FORBIDDEN, "Security Violation: Identity forgery detected.");
}

// 6. 洗白成功,注入上下文(将身份信息存入线程上下文 ThreadLocal)
UserContext.setUserId(Long.parseLong(userId));
UserContext.setRole(role);
return true;

} catch (ExpiredJwtException e) {
// 精准捕获:真正的 10 秒短效时效过期(网络延迟、高并发排队、或 Debug 驻留时间过长)
log.warn("Internal token expired. Detail: {}", e.getMessage());
return sendErrorResponse(response, HttpStatus.FORBIDDEN, "Security Violation: Internal token expired.");
} catch (SignatureException e) {
// 精准捕获:密钥不一致、内网密钥泄露或 Token 被黑客篡改
log.error("Internal token signature verification failed!", e);
return sendErrorResponse(response, HttpStatus.FORBIDDEN, "Security Violation: Invalid signature.");
} catch (Exception e) {
// 其他未知异常(如 JWT 格式错误等)
log.error("Internal security error: ", e);
return sendErrorResponse(response, HttpStatus.FORBIDDEN, "Security Violation: " + e.getMessage());
}
}

/**
* 统一的拦截器错误响应输出器
*/
private boolean sendErrorResponse(HttpServletResponse response, HttpStatus status, String message) throws Exception {
response.setStatus(status.value());
response.setContentType("application/json;charset=UTF-8");
// 也可以选择封装为标准统一的 Result JSON 结构返回
response.getWriter().write(String.format("{\"code\":%d,\"msg\":\"%s\"}", status.value(), message));
return false;
}

@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
// 5. 必须销毁:请求结束时立即销毁 ThreadLocal,防止 Tomcat/Jetty 线程池复用导致上下文串流或内存泄漏
UserContext.clear();
}
}

ReactiveInternalSecurityFilter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import io.jsonwebtoken.Claims;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;
import zdemo.utils.OwliasJwtUtil;

/**
* 响应式环境(WebFlux/网关)专用的内网防伪校验过滤器
*/
public class ReactiveInternalSecurityFilter implements WebFilter {

private final OwliasJwtUtil jwtUtil;
public ReactiveInternalSecurityFilter(OwliasJwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}

@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
String gatewayToken = request.getHeaders().getFirst("X-Gateway-Token");
String userId = request.getHeaders().getFirst("X-User-Id");

// 1. 严格防绕过
if (gatewayToken == null || userId == null) {
exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN);
return exchange.getResponse().setComplete();
}

try {
// 2. 解密内网防伪水印(共享 Starter 里的统一时钟容忍度配置)
Claims claims = jwtUtil.parseInternalToken(gatewayToken);

// 3. 防篡改比对
if (!userId.equals(claims.getSubject())) {
exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN);
return exchange.getResponse().setComplete();
}

// 4. 响应式环境不使用 ThreadLocal 注入上下文(高并发下串流),
// 而是直接把用户信息写进 ServerWebExchange 的 Attributes 中,或者继续往下传递
exchange.getAttributes().put("userId", userId);

return chain.filter(exchange);

} catch (Exception e) {
exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN);
return exchange.getResponse().setComplete();
}
}
}

UserContext

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class UserContext {
private static final ThreadLocal<Long> userIdHolder = new ThreadLocal<>();
private static final ThreadLocal<String> roleHolder = new ThreadLocal<>();

public static void setUserId(Long userId) { userIdHolder.set(userId); }
public static Long getUserId() { return userIdHolder.get(); }

public static void setRole(String role) { roleHolder.set(role); }
public static String getRole() { return roleHolder.get(); }

// 极其重要:防止内存泄漏,必须在请求结束后清除
public static void clear() {
userIdHolder.remove();
roleHolder.remove();
}
}

InternalSecure

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface InternalSecure {
/**
* 是否开启内网水印校验,默认开启
*/
boolean required() default true;

/**
* 允许访问的内网角色,可用于就地细粒度鉴权(可选)
*/
String[] roles() default {};
}


OwliasJwtUtil

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import zdemo.model.constant.Constant;
import zdemo.model.property.OwliasSecurityProperties;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.Objects;

public class OwliasJwtUtil {
public static final String TOKEN_TYPE_OF_ACCESS = "access";
public static final String TOKEN_TYPE_OF_REFRESH = "refresh";

private final OwliasSecurityProperties properties;
private SecretKey externalKey;
private SecretKey internalKey;

public OwliasJwtUtil(OwliasSecurityProperties properties) {
this.properties = properties;
if (Objects.nonNull(properties.getExternalSecret())) {
this.externalKey = Keys.hmacShaKeyFor(properties.getExternalSecret().getBytes(StandardCharsets.UTF_8));
}
if (Objects.nonNull(properties.getInternalSecret())) {
this.internalKey = Keys.hmacShaKeyFor(properties.getInternalSecret().getBytes(StandardCharsets.UTF_8));
}
}

// == 网关层:生成全新的短效 Access Token (30分钟) ==
public String generateAccessToken(String userId, String role) {
return Jwts.builder()
.subject(userId)
//.claims(Map.of("role", role, "type", "access")) // 显式标记 token 类型,这样写会把前面的 sub 给全部洗掉了!
.claim("role", role) // 改为单数 claim 追加,不破坏 sub
.claim("type", TOKEN_TYPE_OF_ACCESS)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + Constant.ACCESS_TOKEN_EXPIRATION))
.signWith(externalKey)
.compact();
}

// == 网关层:生成全新的长效 Refresh Token (7天) ==
public String generateRefreshToken(String userId, String role) {
return Jwts.builder()
.subject(userId)
.claim("role", role)
.claim("type", TOKEN_TYPE_OF_REFRESH)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + Constant.REFRESH_TOKEN_EXPIRATION))
.signWith(externalKey)
.compact();
}

// == 网关层:解析外网 AccessToken ==
public Claims parseExternalToken(String token) {
return Jwts.parser()
.verifyWith(externalKey)
.build()
.parseSignedClaims(token)
.getPayload();
}

// == 网关层:下发内网短效水印(10秒) ==
public String generateInternalWatermark(String userId, String role) {
return Jwts.builder()
.subject(userId)
.claim("role", role)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + Constant.INTERNAL_WATERMARK_EXPIRATION)) // 内网水印在测试期间设置的长一点
.signWith(internalKey)
.compact();
}

// == 下游层:解密内网防伪水印 ==
public Claims parseInternalToken(String token) {
return Jwts.parser()
.verifyWith(internalKey)
.clockSkewSeconds(properties.getClockSkewSeconds()) // 自动应用容忍度
.build()
.parseSignedClaims(token)
.getPayload();
}
}


定义响应格式

ResultCode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Getter
@AllArgsConstructor
public enum ResultCode {

// == 成功 ==
SUCCESS(200, "操作成功"),

// == 客户端错误 (4xx) ==
BAD_REQUEST(400, "请求参数错误"),
UNAUTHORIZED(401, "未登录或登录已过期"),
FORBIDDEN(403, "没有访问权限"),
NOT_FOUND(404, "请求的资源不存在"),
METHOD_NOT_ALLOWED(405, "请求方法不被允许"),
PARAM_IS_BLANK(406, "参数为空"),

// == 安全与风控专属错误 ==
TOKEN_EXPIRED(4011, "凭证已过期,请刷新"),
TOKEN_INVALID(4012, "凭证无效或被篡改"),
IDENTITY_FORGERY(4031, "检测到身份伪造,拒绝访问"),

// == 服务端错误 (5xx) ==
INTERNAL_SERVER_ERROR(500, "系统内部异常,请联系管理员"),
SERVICE_UNAVAILABLE(503, "服务暂时不可用,请稍后重试");

private final int code;
private final String msg;
}

Result

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
/**
* 统一标准 API 响应包装类
* @param <T> 业务数据载荷的类型
*/
@Data
@NoArgsConstructor
public class Result<T> implements Serializable {
@Serial
private static final long serialVersionUID = 1L;

private int code;
private String msg;
private T data;
private long timestamp;

private Result(int code, String msg, T data) {
this.code = code;
this.msg = msg;
this.data = data;
this.timestamp = System.currentTimeMillis();
}

// 快速构建:成功
public static <T> Result<T> success() {
return new Result<>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMsg(), null);
}

public static <T> Result<T> success(T data) {
return new Result<>(ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMsg(), data);
}

public static <T> Result<T> success(String msg, T data) {
return new Result<>(ResultCode.SUCCESS.getCode(), msg, data);
}

// 快速构建:失败
public static <T> Result<T> fail() {
return new Result<>(ResultCode.INTERNAL_SERVER_ERROR.getCode(), ResultCode.INTERNAL_SERVER_ERROR.getMsg(), null);
}

public static <T> Result<T> fail(String msg) {
return new Result<>(ResultCode.INTERNAL_SERVER_ERROR.getCode(), msg, null);
}

// 自定义状态码枚举
public static <T> Result<T> fail(ResultCode resultCode) {
return new Result<>(resultCode.getCode(), resultCode.getMsg(), null);
}

// 自定义状态码枚举 + 覆盖默认提示信息
public static <T> Result<T> fail(ResultCode resultCode, String msg) {
return new Result<>(resultCode.getCode(), msg, null);
}
}

BusinessException

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 统一业务异常对象
* 业务层想中断请求并返回特定错误码时,直接 throw new BusinessException(ResultCode.TOKEN_EXPIRED);
*/
@Getter
public class BusinessException extends RuntimeException {
private final ResultCode resultCode;

public BusinessException(ResultCode resultCode) {
super(resultCode.getMsg());
this.resultCode = resultCode;
}

public BusinessException(ResultCode resultCode, String customMessage) {
super(customMessage);
this.resultCode = resultCode;
}
}

AuthRequest & AuthResult

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 注册 & 登录公用入参
*/
@Data
public class AuthRequest {
private String username;
private String password;
}

@Data
@Accessors(chain = true)
public class AuthResult {
private Long userId;
private String role;
}


定义必要常量

Constant

1
2
3
4
5
6
7
8
9
10
11
12
public interface Constant {
// redis key
String TOKEN_BLACKLIST_USER_KEY_PREFIX = "blacklist:user:";
String USED_REFRESH_TOKEN_KEY_PREFIX = "used_refresh_token:";
int USED_REFRESH_TOKEN_ALLOWED_MINUTES = 5;
int REDIS_FALLBACK_MILLI_SECONDS = 500;

// token
int ACCESS_TOKEN_EXPIRATION = 30 * 60 * 1000;
int REFRESH_TOKEN_EXPIRATION = 7 * 24 * 60 * 60 * 1000;
int INTERNAL_WATERMARK_EXPIRATION = 60000;
}


网关层代码实现

角色和职责

  • 角色定位:网关层是全系统唯一直接暴露在外的组件,控制着对外的流量要道。在微服务架构中,它不仅是流量的路由中枢,更是整个系统认证状态的 “签发所” 和 “第一道防火墙”。
  • 具体职能:
    • 外网凭证唯一持有者:独占 jwt.secret.external 密钥,全系统只有网关有权签发和解密面向前端的 AccessToken 和 RefreshToken。
    • 登录入口和token签发:前端提交账密时,网关拦截请求,仅向下游透传账密,不负责耗时的认证操纵,认证过程移交给用户中心处理。收到用户中心认证成功的信号后,由网关利用外网密钥直接颁发双 Token 扭转给前端。
    • 静默续期与防刷:利用响应式非阻塞流(Mono.defer + flatMap),独立处理高频的 Token 续签,不需要向下游微服务发送任何 RPC 流量。使用 redis 标记防止重放攻击。
    • 全局状态防御:在响应式流中无阻塞地查询 Redis 黑名单,负责实现全局 “主动注销” 或 “强制踢人”。
    • 流量深度清洗:抹除前端带入的 Authorization 契约,防止外网非安全信息向下游污染。将解析出来的用户身份转化为明文的 X-User-Id 和 X-User-Role 注入 Header。
    • 追加内网防伪水印:持有内网共享密钥 jwt.secret.internal,在转发请求前,为 Header 盖上一个只有 10 秒寿命的内网防伪钢印(X-Gateway-Token)。
    • 处理登出:将用户加入 Redis 黑名单,强制让该用户的 Token 失效。


依赖配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<dependency>
<groupId>com.demo</groupId>
<artifactId>zdemo-security-spring-boot-starter</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>


配置文件和启动类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
server:
port: 8080

spring:
application:
name: zdemo-gateway
cloud:
gateway:
routes:
- id: user-route
uri: http://localhost:8090
predicates:
- Path=/user-serv/**
filters:
- StripPrefix=1
data:
redis:
host: localhost
port: 6379

owlias:
security:
# 外网密钥:负责网关与前端之间的 AccessToken/RefreshToken 颁发与校验(长度需 >= 32字符)
externalSecret: "SecureExternalJwtKeyStandardLength32Bytes!"
# 内网密钥:负责网关与下游微服务之间的 10s 极短时效防伪水印签名(长度需 >= 32字符)
internalSecret: "SecureInternalWatermarkKeyStandardLength32Bytes!"

启动类:

1
2
3
4
5
6
@SpringBootApplication
public class GatewayApp {
public static void main(String[] args) {
SpringApplication.run(GatewayApp.class, args);
}
}


全局 token 过滤器

GatewayAuthFilter:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.Claims;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.data.redis.core.ReactiveStringRedisTemplate;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.server.PathContainer;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.util.pattern.PathPattern;
import org.springframework.web.util.pattern.PathPatternParser;
import reactor.core.publisher.Mono;
import zdemo.model.common.Result;
import zdemo.model.constant.Constant;
import zdemo.model.constant.ResultCode;
import zdemo.utils.OwliasJwtUtil;
import java.io.IOException;
import java.time.Duration;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

/**
* 网关层(Gateway):
* 负责拦截外网请求、拦截 Redis 黑名单(踢人)、校验双 Token、清洗并重写 Header、附加短效“内网防伪水印”。
*/
@Slf4j
@Component
public class GatewayAuthFilter implements GlobalFilter, Ordered {

private final OwliasJwtUtil jwtUtil;
private final ReactiveStringRedisTemplate reactiveRedisTemplate;
private final List<PathPattern> whiteListPatterns;

// 引入 Jackson 用于响应式环境下的 JSON 序列化(整体性能优于 fastjson)
private final ObjectMapper objectMapper = new ObjectMapper();

public GatewayAuthFilter(OwliasJwtUtil jwtUtil, ReactiveStringRedisTemplate reactiveRedisTemplate) {
this.jwtUtil = jwtUtil;
this.reactiveRedisTemplate = reactiveRedisTemplate;
//
PathPatternParser parser = new PathPatternParser();
List<String> whiteList = Arrays.asList("/user/login", "/user/register", "/auth/refresh", "/public/**");
this.whiteListPatterns = whiteList.stream()
.map(parser::parse)
.collect(Collectors.toList());
}

@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
PathContainer requestPath = request.getPath().pathWithinApplication();

// 1. 白名单放行逻辑改造
for (PathPattern pattern : whiteListPatterns) {
if (pattern.matches(requestPath)) {
return chain.filter(exchange);
}
}

// 2. 提取 Authorization 头
String authHeader = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
return onError(exchange, HttpStatus.UNAUTHORIZED, ResultCode.UNAUTHORIZED, "Missing or invalid token format");
}
String accessToken = authHeader.substring(7);

try {
// 3. 解析 Token 拿到用户核心数据
Claims claims = jwtUtil.parseExternalToken(accessToken);
// 严格防呆:业务接口只允许 access 类型的 token 通过
String tokenType = claims.get("type", String.class);
if (!OwliasJwtUtil.TOKEN_TYPE_OF_ACCESS.equals(tokenType)) {
return onError(exchange, HttpStatus.FORBIDDEN, ResultCode.TOKEN_INVALID, "Expected Access Token");
}
String userId = claims.getSubject();
String role = claims.get("role", String.class);

// 4. Redis 黑名单核验 (踢人/主动登出场景)。只要黑名单里存在 "blacklist:user:1001" 或当前 token 被拉黑,则拒绝服务
// Redis 异步响应式链式调用,hasKey 返回的是 Mono<Boolean> 交给了上层的 Netty 引擎,绝对不会阻塞当前线程
return reactiveRedisTemplate.hasKey(Constant.TOKEN_BLACKLIST_USER_KEY_PREFIX + userId)
// 核心防灾机制:一旦 Redis 报错(挂了/超时),立刻触发降级(redis 挂了,整体上也不影响网关继续工作)
.timeout(Duration.ofMillis(Constant.REDIS_FALLBACK_MILLI_SECONDS))
.onErrorResume(e -> {
// 1. 打印报警日志,通知运维紧急修复 Redis
log.error("Redis cluster is DOWN or timout! Authentication bypassing blacklist checks, userId:{}", userId, e);
// 2. 选择相信 JWT 自身的合法性,直接放行(这个人不在黑名单里),等 Redis 恢复后再重新审计。
return Mono.just(Boolean.FALSE);
})
.flatMap(isBlack -> {
// 4. Redis 黑名单核验结果回调
if (Boolean.TRUE.equals(isBlack)) {
return onError(exchange, HttpStatus.UNAUTHORIZED, ResultCode.UNAUTHORIZED, "User has been forced offline");
}

// 5. 校验通过,下发内网水印,抹除 Authorization
String internalToken = jwtUtil.generateInternalWatermark(userId, role);
ServerHttpRequest mutatedRequest = request.mutate()
.header("X-User-Id", userId)
.header("X-User-Role", role)
.header("X-Gateway-Token", internalToken)
.headers(httpHeaders -> httpHeaders.remove(HttpHeaders.AUTHORIZATION))
.build();

// 将修改后的 exchange 传递给过滤链的下一个节点
return chain.filter(exchange.mutate().request(mutatedRequest).build());
});
} catch (io.jsonwebtoken.ExpiredJwtException e) {
// 6. 双 Token 机制核心:若 Access 触发过期,提示前端使用 RefreshToken 换取新令牌(返回 401,并在响应头明示错误原因为 TOKEN_EXPIRED)
return onError(exchange, HttpStatus.UNAUTHORIZED, ResultCode.TOKEN_EXPIRED, "Access token expired, please refresh.");
} catch (Exception e) {
// 7. 签名非法或解析失败
return onError(exchange, HttpStatus.UNAUTHORIZED, ResultCode.TOKEN_INVALID, "Signature verification failed");
}
}

private Mono<Void> onError(ServerWebExchange exchange, HttpStatus httpStatus, ResultCode businessCode, String msg) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(httpStatus);
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
Result<Void> errorResult = Result.fail(businessCode, msg);

// 使用 defer 包装,确保在订阅时才真正分配缓冲区和执行序列化
return response.writeWith(Mono.defer(() -> {
// 直接从响应中获取高效的 Netty DataBuffer
DataBuffer buffer = response.bufferFactory().allocateBuffer();
try {
// 让 Jackson 直接把数据写进 DataBuffer 的输出流中,避免中间生成 byte[]
objectMapper.writeValue(buffer.asOutputStream(), errorResult);
return Mono.just(buffer);
} catch (IOException e) {
log.error("Gateway zero-copy serialize failed", e);
// 释放分配的内存,防止内存泄漏
DataBufferUtils.release(buffer);
return Mono.empty();
}
}));
}

@Override
public int getOrder() {
return -1; // 保证在最顶层执行
}
}


登录登出入口

GatewayAuthController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
import io.jsonwebtoken.Claims;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.data.redis.core.ReactiveStringRedisTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import zdemo.model.common.Result;
import zdemo.model.constant.Constant;
import zdemo.model.constant.ResultCode;
import zdemo.model.request.AuthRequest;
import zdemo.model.response.AuthResult;
import zdemo.utils.OwliasJwtUtil;
import java.time.Duration;
import java.util.Map;
import java.util.Objects;

@Slf4j
@RestController
@RequestMapping("/auth")
public class GatewayAuthController {

private final OwliasJwtUtil jwtUtil;
private final WebClient.Builder webClientBuilder;
private final ReactiveStringRedisTemplate reactiveRedisTemplate;

public GatewayAuthController(OwliasJwtUtil jwtUtil,
WebClient.Builder webClientBuilder,
ReactiveStringRedisTemplate reactiveRedisTemplate) {
this.jwtUtil = jwtUtil;
this.webClientBuilder = webClientBuilder;
this.reactiveRedisTemplate = reactiveRedisTemplate;
}

/**
* 全系统唯一的登录入口
*/
@PostMapping("/login")
public Mono<ResponseEntity<? extends Result<?>>> login(@RequestBody AuthRequest loginForm) {
// 使用 ParameterizedTypeReference 明确指定泛型类型
ParameterizedTypeReference<Result<AuthResult>> responseType = new ParameterizedTypeReference<>() {};
return webClientBuilder.build()
.post()
// .uri("http://zdemo-user/user/verify-credentials") // Nacos 注册中心调用写法
// 实际可换成 openfeign、dubbo(不过为了网关的绝对可用而尽量减少第三方服务依赖,可以自己封装一个 LoadBalance 去调用用户中心节点)
.uri("http://localhost:8090/user/verify-credentials")
.bodyValue(loginForm)
.retrieve()
.bodyToMono(responseType) // 异步非阻塞请求下游认证
.timeout(Duration.ofSeconds(3)) // 设置超时时间
.flatMap(result -> {
// 1. 如果下游业务码提示失败(账密错误等),网关透传其失败原因并包装为标准格式
if (!Objects.equals(result.getCode(), ResultCode.SUCCESS.getCode())) {
return Mono.just(ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body(Result.fail(ResultCode.UNAUTHORIZED, result.getMsg())));
}

// 2. 如果下游用户中心响应为空
AuthResult authResult = result.getData();
if (authResult == null) {
return Mono.just(ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Result.fail(ResultCode.INTERNAL_SERVER_ERROR, "User info missing from microservice")));
}
String userId = String.valueOf(authResult.getUserId());
String role = authResult.getRole();

// 3. 如果用户之前是被踢下线或封禁,用户重新登录成功要把黑名单移除(由于生成了全新的双Token,旧的双token可以直接不管)
String blacklistKey = Constant.TOKEN_BLACKLIST_USER_KEY_PREFIX + userId;
return reactiveRedisTemplate.delete(blacklistKey)
// 用户体验至上:如果 Redis 挂了,吞掉异常,返回一个默认值(比如 0L),保证核心的认证逻辑
.timeout(Duration.ofMillis(500))
.onErrorReturn(-1L)
.map(deleteCount -> {
// 如果 deleteCount 为 -1,说明删除出现异常,redis 需要立即维护,但redis超时或挂了不影响发新Token
if (deleteCount == -1) {
log.warn("Redis cluster is DOWN or timeout! User login bypassing token outbound check, userId:{}", userId);
}

// 4. 账密正确,由网关在入口处统一 “盖章颁发” 双 Token
String accessToken = jwtUtil.generateAccessToken(userId, role);
String refreshToken = jwtUtil.generateRefreshToken(userId, role);
Map<String, String> tokenMap = Map.of(
"accessToken", accessToken,
"refreshToken", refreshToken
);
return ResponseEntity.ok(Result.success(tokenMap));
});

})
// 5. 异常处理:下游微服务宕机、网络超时或网络熔断时的兜底策略
.onErrorResume(e -> {
log.error("Login process encountered an unhandled exception", e);
return Mono.just(ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Result.fail(ResultCode.INTERNAL_SERVER_ERROR, "User service unavailable")));
});
}

/**
* 主动登出 / 全局会话销毁(移至网关层)
* 网关的 controller 方法并不会走 GatewayAuthFilter
*/
@PostMapping("/logout")
public Mono<ResponseEntity<Result<Object>>> logout(ServerWebExchange exchange) {
// 1. 直接从请求头中获取 Authorization
String authHeader = exchange.getRequest().getHeaders().getFirst("Authorization");
String userId = null;
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
try {
Claims claims = jwtUtil.parseExternalToken(token);
userId = claims.getSubject();
} catch (Exception e) {
log.warn("Logout failed: Invalid or expired token", e);
return Mono.just(ResponseEntity.ok(Result.success("注销成功", null)));
}
}

// 2. 如果连 Token 都没有或者解析不出 userId,直接放行(反正也是无效会话)
if (userId == null) {
return Mono.just(ResponseEntity.ok(Result.success("注销成功", null)));
}

// 3. 将用户加入 Redis 黑名单,强制让该用户的 Token 失效
String blacklistKey = Constant.TOKEN_BLACKLIST_USER_KEY_PREFIX + userId;
String finalUserId = userId;
return reactiveRedisTemplate.opsForValue()
.set(blacklistKey, "logged_out", Duration.ofMillis(Constant.ACCESS_TOKEN_EXPIRATION))
.map(success -> ResponseEntity.ok(Result.success("注销成功", null)))
// 防御 Redis 崩溃
.onErrorResume(e -> {
log.error("Redis error during logout for userId:{}", finalUserId, e);
return Mono.just(ResponseEntity.ok(Result.success("注销成功(未同步缓存)", null)));
});
}
}


Token 置换入口

GatewayRefreshController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
import io.jsonwebtoken.Claims;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.ReactiveStringRedisTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
import zdemo.model.common.Result;
import zdemo.model.constant.Constant;
import zdemo.model.constant.ResultCode;
import zdemo.utils.OwliasJwtUtil;
import java.time.Duration;
import java.util.Map;

@Slf4j
@RestController
@RequestMapping("/auth")
public class GatewayRefreshController {

private final OwliasJwtUtil jwtUtil;
private final ReactiveStringRedisTemplate reactiveRedisTemplate;

public GatewayRefreshController(OwliasJwtUtil jwtUtil, ReactiveStringRedisTemplate reactiveRedisTemplate) {
this.jwtUtil = jwtUtil;
this.reactiveRedisTemplate = reactiveRedisTemplate;
}

/**
* 双 Token 置换核心接口(彻底异步非阻塞版)
*/
@PostMapping("/refresh")
public Mono<ResponseEntity<? extends Result<?>>> refresh(@RequestBody Map<String, String> body) {
String oldRefreshToken = body.get("refreshToken");
if (oldRefreshToken == null) {
return Mono.just(ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(Result.fail(ResultCode.PARAM_IS_BLANK, "Missing refresh token")));
}

// 使用 Mono.defer 将同步的 JWT 解析包装进响应式流中,方便统一异常处理
return Mono.defer(() -> {
// 1. 校验并解密传入的旧 Refresh Token
Claims claims = jwtUtil.parseExternalToken(oldRefreshToken);

// 2. 安全防范 A:确保它是标准的 refresh 类型
String tokenType = claims.get("type", String.class);
if (!OwliasJwtUtil.TOKEN_TYPE_OF_REFRESH.equals(tokenType)) {
return Mono.just(ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(Result.fail(ResultCode.TOKEN_INVALID, "Invalid token type")));
}

String userId = claims.getSubject();
String role = claims.get("role", String.class);

String blacklistKey = Constant.TOKEN_BLACKLIST_USER_KEY_PREFIX + userId;
String tokenReuseKey = Constant.USED_REFRESH_TOKEN_KEY_PREFIX + Math.abs(oldRefreshToken.hashCode());

/**
* [Netty EventLoop 线程] ────发送 hasKey(黑名单) 指令────> [网卡] ────> 注册到同一个 Epoll Selector
* [Netty EventLoop 线程] ────发送 hasKey(防重放) 指令────> [网卡] ────> 注册到同一个 Epoll Selector
* [Netty EventLoop 线程] ────发送 set(标记已用) 指令────> [网卡] ────> 注册到同一个 Epoll Selector
* 响应式里 Mono 嵌套 Mono 的代码形态,在编译后本质上被展开为了一个 “状态机(State Machine)”。每一层 flatMap,其实只是在状态机上注册了一个 OnNext 观察者回调。
*
* 1. 线程X 执行最外层,触发了第一个顶层动作:reactiveRedisTemplate.hasKey(blacklistKey)。
* │
* ├─► 动作:将「黑名单Socket」挂到 Selector 上。
* └─► 动作:线程X 立刻 return,释放,去干别的事。 (绝不死等结果!)
*
*
* 2. 过了几毫秒,Redis 回包了。Selector 监听到「黑名单Socket」可读。
* │
* ├─► 动作:抓起 线程Y,把回包数据喂给第一个 flatMap。
* └─► 触发:此时,后面的 return 终于被触发了!开始执行第二个动作:hasKey(tokenReuseKey)。
* │
* ├─► 动作:将「防重放Socket」挂到同一个 Selector 上。
* └─► 动作:线程Y 立刻 return,释放,去干别的事。 (依然绝不死等!)
*
*
* 3. 又过了几毫秒,Redis 再次回包。Selector 监听到「防重放Socket」可读。
* │
* ├─► 动作:抓起 线程Z,把数据喂给第二个 flatMap。
* └─► 触发:最后的 return 被触发!开始执行:opsForValue().set(...)。
* │
* ├─► 动作:将「写入Socket」挂到同一个 Selector 上。
* └─► 动作:线程Z 立刻 return,释放。 (依然不等!)
*/

// 3. 异步检查黑名单(带 500ms 超时防死锁,发生异常默认返回 false 降级通过,用户体验至上)
return reactiveRedisTemplate.hasKey(blacklistKey)
.timeout(Duration.ofMillis(Constant.REDIS_FALLBACK_MILLI_SECONDS))
.onErrorReturn(false)
.flatMap(isBlacklisted -> {
if (Boolean.TRUE.equals(isBlacklisted)) {
return Mono.just(ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body(Result.fail(ResultCode.UNAUTHORIZED, "Session has been terminated")));
}

// 4. 异步检查 Token 是否已被复用重放
return reactiveRedisTemplate.hasKey(tokenReuseKey)
.timeout(Duration.ofMillis(Constant.REDIS_FALLBACK_MILLI_SECONDS))
.onErrorReturn(false)
.flatMap(isReused -> {
if (Boolean.TRUE.equals(isReused)) {
return Mono.just(ResponseEntity
.status(HttpStatus.FORBIDDEN)
.body(Result.fail(ResultCode.TOKEN_INVALID, "Security Alert: Token reuse detected!")));
}

// 5. 生成全新的双 Token
String newAccessToken = jwtUtil.generateAccessToken(userId, role);
String newRefreshToken = jwtUtil.generateRefreshToken(userId, role);

Map<String, String> tokenMap = Map.of(
"accessToken", newAccessToken,
"refreshToken", newRefreshToken
);

// 6. 将旧 Token 标记为已消费,作废 5 分钟(通过 flatMap 编排进流,确保写入动作被触发)
return reactiveRedisTemplate.opsForValue()
.set(tokenReuseKey, "used", Duration.ofMinutes(Constant.USED_REFRESH_TOKEN_ALLOWED_MINUTES))
.timeout(Duration.ofMillis(Constant.REDIS_FALLBACK_MILLI_SECONDS))
// 即使写入防刷标记失败,也允许用户完成这次置换,保障可用性
.onErrorReturn(false)
.map(success -> ResponseEntity.ok(Result.success(tokenMap)));
});
});
})
// 7. 统一捕获 JWT 解析异常(过期、被篡改等)
.onErrorResume(e -> {
log.warn("Refresh token validation failed or expired: {}", e.getMessage());
return Mono.just(ResponseEntity
.status(HttpStatus.FORBIDDEN)
.body(Result.fail(ResultCode.TOKEN_EXPIRED, "Session expired or invalid. Please login again.")));
});
}
}


用户中心

角色和职责

  • 角色定位:纯粹的 “账密核验器” 与数据资产库。它退居内网深处,剥离了所有关于Token/分布式会话维持的逻辑。它不再关心什么是 JWT,也不关心 Token 什么时候过期,它只对数据库和最底层的用户身份认证负责。
  • 具体职能:
    • 身份认证:比如通过用户提供的用户名密码、手机验证码、二维码、或者人脸进行认证核验。一旦账密核验成功,它不需要生成任何 Token,而是仅仅向网关返回干净、纯粹的实体信息(如 userId、roleId 等)。
    • 传统用户域业务维护:负责处理正常的、与认证状态保持无关的用户业务,如修改个人资料、注册新用户、绑定手机号、找回密码等。


依赖配置

1
2
3
4
5
6
7
8
9
10
11
12
13
<dependencies>
<!-- Spring Boot Web 基础 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>com.demo</groupId>
<artifactId>zdemo-security-spring-boot-starter</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>


配置文件和启动类

application.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
server:
port: 8090
servlet:
encoding:
force: true
enabled: true
force-request: true
force-response: true
charset: UTF-8

spring:
application:
name: zdemo-user
data:
redis:
host: localhost
port: 6379
connect-timeout: 2000
timeout: 500

owlias:
security:
internalSecret: "SecureInternalWatermarkKeyStandardLength32Bytes!"
excludePathPatterns: /user/verify-credentials, /user/register

App

1
2
3
4
5
6
@SpringBootApplication
public class App {
public static void main(String[] args) {
SpringApplication.run(App.class, args);
}
}


认证实现接口

UserController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@RestController
@RequestMapping("/user")
public class UserController {

/**
* 登录认证变成了内网接口,专供网关发起 RPC 强匹配调用
*/
@PostMapping("/verify-credentials")
@InternalSecure(required = false)
public Result<AuthResult> verifyCredentials(@RequestBody AuthRequest request) {
// 测试用
AuthResult resultVo = new AuthResult().setUserId(1L).setRole("ADMIN");
return Result.success(resultVo);
}

/**
* 获取用户列表(用于演示)
*/
@GetMapping("/list")
@InternalSecure
public Result<List<String>> getUserList() {
// 从本进程的局部线程中捞取角色信息
String currentRole = UserContext.getRole();

if (!"ADMIN".equalsIgnoreCase(currentRole)) {
throw new BusinessException(ResultCode.FORBIDDEN, "权限不足:只有管理员可访问用户列表");
}

List<String> userList = Arrays.asList("UserA", "UserB");
return Result.success(userList);
}
}


全局异常处理

GlobalExceptionHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

/**
* 拦截可预期的、主动抛出的业务逻辑异常
*/
@ExceptionHandler(BusinessException.class)
public Result<?> handleBusinessException(BusinessException ex) {
log.warn("Business warning: code={}, msg={}", ex.getResultCode().getCode(), ex.getMessage());
// 传递枚举状态码,并允许覆盖具体的错误描述(ex.getMessage())
return Result.fail(ex.getResultCode(), ex.getMessage());
}

/**
* 拦截 NoResourceFoundException
*/
@ExceptionHandler(NoResourceFoundException.class)
public Result<?> handleNoResourceFoundException(NoResourceFoundException ex) {
log.error("NoResourceFoundException occurred: ", ex);
return Result.fail(ResultCode.NOT_FOUND, ex.getMessage());
}

/**
* 拦截未知运行时异常(如 NullPointerException, IllegalArgumentException 等)
*/
@ExceptionHandler(RuntimeException.class)
public Result<?> handleRuntimeException(RuntimeException ex) {
log.error("Uncaught runtime exception occurred: ", ex);
// 运行时异常通常视为客户端传参错误(400)或通用的 500,这里按 400 参数错误兜底,并暴露具体报错信息
return Result.fail(ResultCode.BAD_REQUEST, ex.getMessage());
}

/**
* 兜底拦截意料之外的系统崩溃(如数据库断开、OOM 等严重异常)
*/
@ExceptionHandler(Exception.class)
public Result<?> handleGenericException(Exception ex) {
log.error("Critical system error occurred: ", ex);
// 处于安全考虑,绝对不能向外网暴露未知的系统底层堆栈(防止信息泄露),统一返回“系统内部异常”
return Result.fail(ResultCode.INTERNAL_SERVER_ERROR);
}
}